"use client"; import * as React from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ArrowLeft, Ban, CheckCircle2, Scale, UserCheck, XCircle } from "lucide-react"; import { Button, Tabs } from "@/components/ui"; import { api } from "@/lib/api"; import { toast } from "@/lib/store"; import { timeAgo } from "@/lib/utils"; import { useAdminQuery } from "@/components/admin/use-query"; import type { LedgerRow, RoundRow, SecurityEventRow, SessionRow, UserDetailResponse } from "@/components/admin/types"; import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, KV, Pill, UserStatusPill, SeverityPill, ConfirmDialog, DenseInput, DenseTextarea, FieldLabel, InlineError, JsonToggle, Mono, StatTile, StatGrid, type Column } from "@/components/admin/primitives"; import { dateTime, describeError, int, multiplier, sc, signedSC } from "@/components/admin/format"; import { cn } from "@/lib/utils"; type Tab = "ledger" | "rounds" | "sessions" | "events"; const ledgerCols: Column[] = [ { key: "at", header: "When", render: (r) => {dateTime(r.createdAt)} }, { key: "type", header: "Type", render: (r) => {r.type} }, { key: "amount", header: "Amount", align: "right", render: (r) => 0 ? "text-success" : r.amount < 0 ? "text-fg-2" : "text-fg-3")}>{signedSC(r.amount)} }, { key: "after", header: "Balance after", align: "right", render: (r) => sc(r.balanceAfter) }, { key: "ref", header: "Reference", mono: true, render: (r) => {r.reference ?? "—"} }, { key: "meta", header: "Meta", render: (r) => }, ]; const roundCols: Column[] = [ { key: "at", header: "When", render: (r) => {dateTime(r.createdAt)} }, { key: "game", header: "Game", render: (r) => {r.gameSlug} }, { key: "bet", header: "Bet", align: "right", render: (r) => sc(r.bet) }, { key: "win", header: "Win", align: "right", render: (r) => 0 ? "text-credit" : "text-fg-3"}>{sc(r.win)} }, { key: "mult", header: "×", align: "right", render: (r) => multiplier(r.multiplier) }, { key: "features", header: "Features", render: (r) => (r.features.length ? {r.features.join(", ")} : —) }, { key: "flags", header: "", render: (r) => {r.freeSpins ? FS : null}{r.bonus ? Bonus : null}{r.jackpotTier ? {r.jackpotTier} : null} }, { key: "after", header: "Balance after", align: "right", render: (r) => sc(r.balanceAfter) }, { key: "ms", header: "ms", align: "right", render: (r) => r.durationMs ?? "—" }, { key: "id", header: "Round", mono: true, render: (r) => {r.roundId} }, ]; const sessionCols: Column[] = [ { key: "created", header: "Started", render: (r) => {dateTime(r.createdAt)} }, { key: "seen", header: "Last seen", render: (r) => timeAgo(r.lastSeenAt) }, { key: "expires", header: "Expires", render: (r) => {dateTime(r.expiresAt)} }, { key: "ip", header: "IP", mono: true, render: (r) => {r.ip ?? "—"} }, { key: "ua", header: "User agent", render: (r) => {r.userAgent ?? "—"} }, ]; const eventCols: Column[] = [ { key: "at", header: "When", render: (r) => {dateTime(r.createdAt)} }, { key: "type", header: "Type", mono: true, render: (r) => {r.type} }, { key: "sev", header: "Severity", render: (r) => }, { key: "ip", header: "IP", mono: true, render: (r) => {r.ip ?? "—"} }, { key: "meta", header: "Meta", render: (r) => }, ]; export default function AdminUserDetailPage() { const { id } = useParams<{ id: string }>(); const q = useAdminQuery(id ? `/api/admin/users/${id}` : null); const d = q.data; const [tab, setTab] = React.useState("ledger"); const [adjustOpen, setAdjustOpen] = React.useState(false); const [statusOpen, setStatusOpen] = React.useState(false); return ( <> {d.user.id} : undefined} actions={ <> void q.refresh()} loading={q.refreshing} /> } /> {q.error && !d ? ( void q.refresh()} title={q.error.status === 404 ? "User not found" : undefined} /> ) : !d ? (
) : (
{/* Profile */} }>
{d.user.status === "suspended" ? ( ) : ( )}
{/* Wallet */} ledger = balance ) : ( ledger ≠ balance ) } >
Balance
{sc(d.wallet?.balance ?? 0)}
Ledger invariant
Σ ledger amounts {sc(d.invariant.ledgerTotal)}
Wallet balance {sc(d.invariant.balance)}
{!d.invariant.ok ?
Mismatch of {signedSC(d.invariant.balance - d.invariant.ledgerTotal)} — investigate before adjusting.
: null}
{/* Activity snapshot */}
e.severity === "high") ? "danger" : "neutral"} sub="latest 30" compact /> r.bonus || r.freeSpins).length)} compact />
value={tab} onChange={setTab} items={[ { value: "ledger", label: `Ledger (${d.ledger.length})` }, { value: "rounds", label: `Rounds (${d.rounds.length})` }, { value: "sessions", label: `Sessions (${d.sessions.length})` }, { value: "events", label: `Security (${d.events.length})` }, ]} /> } > {tab === "ledger" ? r.id} dense empty="No ledger entries." /> : null} {tab === "rounds" ? r.id} dense empty="No rounds played." /> : null} {tab === "sessions" ? r.id} dense empty="No sessions." /> : null} {tab === "events" ? r.id} dense empty="No security events for this user." /> : null} setAdjustOpen(false)} userId={d.user.id} username={d.user.username} balance={d.wallet?.balance ?? 0} onDone={() => void q.refresh()} /> setStatusOpen(false)} userId={d.user.id} username={d.user.username} suspended={d.user.status === "suspended"} onDone={() => void q.refresh()} />
)} ); } function roundRtp(rounds: RoundRow[]): string { const bet = rounds.reduce((a, r) => a + r.bet, 0); const win = rounds.reduce((a, r) => a + r.win, 0); return bet ? `${((win / bet) * 100).toFixed(1)}%` : "—"; } function AdjustDialog({ open, onClose, userId, username, balance, onDone }: { open: boolean; onClose: () => void; userId: string; username: string; balance: number; onDone: () => void }) { const [amount, setAmount] = React.useState(""); const [note, setNote] = React.useState(""); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const [confirm, setConfirm] = React.useState(false); const n = Number(amount); const amountOk = /^-?\d+$/.test(amount.trim()) && n !== 0 && Math.abs(n) <= 1_000_000_000; const noteOk = note.trim().length >= 3; const after = balance + (amountOk ? n : 0); function reset() { setAmount(""); setNote(""); setError(null); setConfirm(false); } async function submit() { if (!amountOk || !noteOk) return; setBusy(true); setError(null); try { const r = await api<{ balance: number }>(`/api/admin/users/${userId}/adjust`, { json: { amount: n, note: note.trim() } }); toast({ title: "Wallet adjusted", description: `${username} · ${signedSC(n)} → ${sc(r.balance)}`, tone: "success" }); reset(); onClose(); onDone(); } catch (e) { setError(describeError(e)); setConfirm(false); } finally { setBusy(false); } } return ( { if (busy) return; reset(); onClose(); }} onConfirm={() => (confirm ? void submit() : setConfirm(true))} title={confirm ? "Confirm wallet adjustment" : `Adjust wallet · ${username}`} description={confirm ? undefined : "Signed amount in Spinza Credits (SC). Positive grants, negative removes. Logged as a high-severity security event."} confirmLabel={confirm ? `Apply ${signedSC(amountOk ? n : 0)}` : "Review"} danger={amountOk && n < 0} loading={busy} disabled={!amountOk || !noteOk} > {confirm ? (
Player {username}
Adjustment 0 ? "text-success" : "text-danger")}>{signedSC(n)}
Balance {sc(balance)} → {sc(after)}
“{note.trim()}”
{after < 0 ? Resulting balance would be negative. : null}
) : (
Amount (SC) setAmount(e.target.value.replace(/[^\d-]/g, ""))} className="font-mono" />
Note setNote(e.target.value)} />
{[1000, 5000, 10000, -1000].map((v) => ( ))}
)} {error ? (
{error}
) : null}
); } function StatusDialog({ open, onClose, userId, username, suspended, onDone }: { open: boolean; onClose: () => void; userId: string; username: string; suspended: boolean; onDone: () => void }) { const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const next = suspended ? "active" : "suspended"; async function submit() { setBusy(true); setError(null); try { await api(`/api/admin/users/${userId}/status`, { json: { status: next } }); toast({ title: next === "suspended" ? "Account suspended" : "Account reactivated", description: username, tone: next === "suspended" ? "danger" : "success" }); onClose(); onDone(); } catch (e) { setError(describeError(e)); } finally { setBusy(false); } } return ( void submit()} title={suspended ? `Reactivate ${username}?` : `Suspend ${username}?`} description={suspended ? "The player will be able to sign in and play again." : "All active sessions are revoked immediately and the player cannot sign in until reactivated. Credits and progress are kept."} confirmLabel={suspended ? "Reactivate" : "Suspend"} danger={!suspended} loading={busy}> {error ? {error} : null} ); }